Repository navigation
fix(mcp,runtime): describe_object serves readonly: true where a write never sets the value - #33150
Conversation
…ver sets Claude-Session: https://claude.ai/code/session_01L9qibzwtSQM5u87bBFCujF Co-Authored-By: Claude <noreply@anthropic.com>
… sets the value A runtime-owned type, a formula and a stored field declaring an expression are served readonly: true, beside the declared flag. The describe_object description says readonly is the schema's, not the caller's edit permission, and that a defaultValue may be a token or an expression the server resolves. Claude-Session: https://claude.ai/code/session_01L9qibzwtSQM5u87bBFCujF Co-Authored-By: Claude <noreply@anthropic.com>
… changeset Claude-Session: https://claude.ai/code/session_01L9qibzwtSQM5u87bBFCujF Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzwtSQM5u87bBFCujF Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c272bbaa8ae3e62e1df8bf4ee630c9972c6d97f2 && git checkout c272bbaa8ae3e62e1df8bf4ee630c9972c6d97f2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 128b6571f476d4fae347d8f860b04b95927d2cce 92fba691f927d91ef8ac9103042b8ef7236de7b0 && git checkout -B drift-repro 128b6571f476d4fae347d8f860b04b95927d2cce && git merge --no-ff 92fba691f927d91ef8ac9103042b8ef7236de7b0
node scripts/docs-audit/affected-docs.mjs --json 128b6571f476d4fae347d8f860b04b95927d2cce
|
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…ot the masked one The caller's field mask deletes a readable field's expression when it names a field the caller cannot read; the engine still computes that field. The HTTP bridge now reads only the boolean from the declaration it masks and keeps serving the masked definition. Claude-Session: https://claude.ai/code/session_01L9qibzwtSQM5u87bBFCujF Co-Authored-By: Claude <noreply@anthropic.com>
…n each transport Claude-Session: https://claude.ai/code/session_01L9qibzwtSQM5u87bBFCujF Co-Authored-By: Claude <noreply@anthropic.com>
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx Contract reviewServed-tier: ① Derived judgmentsThe delta from
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx 维护者速读 · PR #33150 @
|
Requested by Tesla · project thread
Fixes #32530
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
describe_object(HTTP 与 stdio 两种传输)和objectstack://objects/{name}资源,现在对「写了也不会生效」的字段回答readonly: true:自动编号(包括手写、没带 readonly 标记的字段)、公式字段、声明了expression的存储计算字段。HTTP 传输按字段的原始声明判断(调用者看不到表达式引用的字段时,表达式会被遮蔽,但仍回答readonly: true,表达式本身不外露);stdio 与资源只拿得到遮蔽后的对象,这一情形的缺口已另立 mcp: stdio describe_object and the objects resource serve readonly: false for a stored expression field whose expression the caller's field mask hides, while the HTTP door answers true #33156。工具说明同时写明:readonly是 schema 的判断,不是调用者的字段编辑权限;defaultValue可能是服务端解析的令牌(NOW()、current_user)或表达式对象,代理应省略该字段而不是照抄。readonly标记,代理会以为这些字段可写,写进去被引擎丢弃或覆盖,再猜再试。readonly: true),不拒绝任何新输入;普通字段仍是false、声明了只读的仍是true(有对照用例)。回滚 = revert 本 PR,不涉及数据。packages/runtime/src/domains/mcp.ts与packages/mcp/src/mcp-server-runtime.ts新增的writeNeverSetsField(两处孪生实现);packages/mcp/src/mcp-http-tools.ts的describe_object说明文字。pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/mcp-object-schema-mask.test.ts -t 32530,预期 4/4 通过(真实引擎 + 安全插件)。What changes
describeServedObjectinpackages/runtime/src/domains/mcp.ts(HTTP bridge) and inpackages/mcp/src/mcp-server-runtime.ts(stdio bridge and theobjectstack://objects/{name}resource) now servereadonlyfromwriteNeverSetsField(f), one small predicate per twin: the declared flag, OR a runtime-owned type (RUNTIME_OWNED_FIELD_TYPESfrom@objectstack/spec/data, todayautonumber), OR aformula(isVirtualSearchField, the spec's one statement of the virtual set; the engine's write strip keys on the same type), OR a stored field declaring anexpression(the spec'sexpressioncontract: "a client-sent value never stands"; truthy, the engine's own test inplanStoredExpressions). No local type list.expressionwhen it names a field the caller cannot read, while the engine still computes the column.describeObjectinpackages/runtime/src/domains/mcp.tsnow runsreadServedObjectandservedToCalleras two steps and callsdescribeServedObject(def, declared):readonlycomes fromwriteNeverSetsField(declared.fields[k]), and everything served is still built from the maskeddef, so the expression stays hidden. No new read:declaredis the object the bridge already masks. The stdio twin and the objects resource hold only the masked object (callerObjectView), so there the expression case answerstrueonly where the caller is shown the expression; the mcp twin's JSDoc and the changeset say so.expressionis the same defect (servedreadonly: false, the engine overwrites the sent value, measured below), on the same line, with the same gate family, and no other claim holds the function. Leaving it would keep the card's complaint for that field kind.servedToCaller(def)overresolveObjectMasker→resolveObjectSchemaMaskPosture, which asks the security servicegetMetadataReadableFields/getReadableFieldsandcanReadObjectonly; the posture carries areadableset and nothing about edit. The edit verdict lives inISecurityService.getWritableFields(object, context)(optional member, the write gate's complement). So the description now saysreadonlyis the schema's, not the caller's edit permission, and that a write naming a read-only-for-you field is refused. What an edit verdict would need is in the acceptance notes.optionFieldsOfJSDoc no longer saysdescribe_object"serves what it served before"; thedescribe_objectdescription gains thedefaultValuesentence (token or expression object: omit the field, never copy the value) and thereadonlywording above.packages/mcp/src/skill-md.tsnamesdescribe_object(fields: name, type, required) but restates neitherreadonlynordefaultValue, so it and its console mirror are untouched.Tests
packages/runtime/src/domains/mcp-object-schema-mask.test.ts, new top-level describe[#32530]on the file's existing real-layer harness (realObjectQLoverSqlDriver, realSecurityPlugin, the real/mcpentry):autonumberliteral reaches the served object with noreadonlyflag;ticket_no(autonumber),total(formula),bare_formula(formula, no expression),doubled(number + expression) are servedreadonly: true; CONTROLtitleandqtystayfalse,frozen_note(declared readonly) staystrue;create, the engine stores none of the sent values: the autonumber is issued (T-0001shape), the formula reads6, the stored expression is6, the CONTROL field is stored;locked_note(member may read, not edit) is servedreadonly: false, andcreatenaming it is refusedPERMISSION_DENIED/403;marked_upis a stored expression overhidden_cost, which the member may not read. It is servedreadonly: true, and the served answer contains neitherhidden_costnor anyrecord.text. Preconditions: the member's/metadata/objectread servesmarked_upwith noexpression, whileengine.getSchemastill carries it.packages/mcp/src/stdio-data-bridge.object-schema-mask.test.ts: new top-level describe[#32530]pinning the same fields over stdiodescribe_objectand overbuildObjectSchemaResource; the existingDECLARED_ANSWERpin hadbonus_formulaatreadonly: false, which encoded the defect, and now readstrue.Acceptance notes
security.getWritableFields(name, ec)call perdescribe_object, feature-detected, beside the masker (the service is already resolved there). stdio bridge:StdioDataBridgeDeps.securityis typedObjectSchemaMaskSecuritySurface, which has nogetWritableFields, so the dependency type andcallerObjectViewwiden (stdio-data-bridge.ts, outside this card's surface). The objects resource:buildObjectSchemaResourcereads anMcpObjectView(listObjects/getObject) with no principal, so it cannot ask at all without a new seam. And the contract (getWritableFieldsJSDoc) requires a consumer without that member to state in its response that it narrowed by the read projection, so the answer would also gain a coverage key. Not filed: it is the card's own option (a), deliberately not taken.objectstack://objects/{name}resource, a caller who cannot read a field that a stored expression names is servedreadonly: falsefor that field, while the HTTP door answerstrue. Those two readers hold only the masked object; the declaration is not reachable there without a second metadata read, andstdio-data-bridge.tsis outside this card's surface.summaryis leftreadonly: false.COMPUTED_VALUE_TYPEScalls it server-computed, but the engine persists a caller-sent summary value (stripComputedWriteFieldsJSDoc: "a roll-up HAS a column, and a caller-supplied value is persisted"), soreadonly: truewould claim a drop that does not happen. Noted, not filed: the engine records the question as its own.isVirtualSearchFieldis the spec's only exported statement of "computed on read, no column"; the write strip in objectql keys ontype === 'formula'literally andisRuntimeOwnedFieldlives in objectql, which@objectstack/mcpdoes not depend on. Both read the same set today. A write-named spec predicate would be its own spec change.describeServedObject/writeNeverSetsField, theoptionFieldsOfJSDoc and thedescribe_objectdescription; no hunk touchesservedObjectDefinitionOf, thevalidate_expressionhandler (fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134) or the connector narrowing (objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699).ruling|Ruling-ref|maintainer|⛔) on the touched surface: 69 hits across the three source files. The ones inside the edited regions are thedescribeServedObjectJSDoc's "⛔ never its condition, pattern or transition table" (both twins, unchanged, honoured: no rule expression is served) and the new JSDoc's own "⛔ Not the caller's field edit permission". No ruling record governsreadonly. Themcp-http-tools.tshits sit outside the edited JSDoc and description.Verification
Round 1 readings were taken at
819ea267(base932eab54;origin/mainmerged once there, bringing onlypackages/lintchanges). Round 2 readings were taken at head92fba691: the S1 fixd05eae15, the changeset narrowing, thenorigin/mainmerged a second time because #33144 landed onpackages/runtime/src/domains/mcp.ts(no conflict).Round 2 (S1) at 92fba69
mcp-object-schema-mask.test.ts(runtime) — Tests 72 passed (72);src/domains/mcp*(runtime) — Test Files 5 passed (5), Tests 247 passed (247)pnpm --filter @objectstack/mcp test— Test Files 63 passed (63), Tests 1106 passed (1106)pnpm --filter @objectstack/mcp --filter @objectstack/runtime typecheck—VERDICT command-exit 0readonly: writeNeverSetsField(f)back in place gives 1 failed (the newmarked_upcase); servingdeclared.fields[k].expressiongives 1 failed (the same case, its no-expression assertion).92fba691: the same 66 families, each run with stdin closed and its exit code recorded;--ranreconciles 66 accounted, 66 run, 0 NOT MEASURED, every one exit 0 (check:dual-build-cjs-loadsincluded this time, after the post-merge closure build).pnpm lintexit 0.Measured at origin/main first (the premise holds)
The new runtime pins over the unfixed twin (
4fee53be, tests only on932eab54): 1 failed | 3 passed. The failing case:doubled,ticket_noandtotalwere servedreadonly: false(thefrozen_noteand plain-field controls matched). The three passing cases are the measurement of the engine and the gate: the autonumber literal carries no flag, the engine dropped or replaced every sent computed value, and the member's write naminglocked_notewas refusedPERMISSION_DENIED/403whiledescribe_objectserved itreadonly: false. On the stdio side,DECLARED_ANSWERpinnedbonus_formulaatreadonly: falseonmain.Package tests and typecheck
pnpm --filter @objectstack/mcp test— Test Files 63 passed (63), Tests 1106 passed (1106)pnpm --filter @objectstack/runtime test— Test Files 596 passed (596), Tests 10581 passed | 19 skipped (10600),VERDICT command-exit 0mcp-object-schema-mask.test.ts(runtime) — Tests 71 passed (71)pnpm --filter @objectstack/mcp --filter @objectstack/runtime typecheck—VERDICT command-exit 0; both runtsc --noEmit && pnpm check:test-typecheck, the second overtsconfig.test.json, which carries the test filesAblations (12, all red, all restored)
Every leg through
scripts/ablation-replace.mjs(anchor x1 → x0, blob changed, restored blob == HEAD andgit diff HEADempty). The suites import the source, so nodist/leg.-t 32530: whole verdict back tof?.readonly === true→ 1 failed; runtime-owned arm removed → 1 failed; formula arm removed → 1 failed; expression arm →false→ 1 failed; declared arm removed → 1 failed; CONTROL (last arm →true) → 2 failed (the plain-field control and the item-2locked_notecase).stdio-data-bridge.object-schema-mask.test.ts: whole verdict → 4 failed (the two new cases plus the twoDECLARED_ANSWERcases); runtime-owned arm → 2 failed; formula arm → 2 failed; expression arm → 2 failed; declared arm → 3 failed; CONTROL → 5 failed.Derived gates and lint
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat819ea267: 66 families, each run with stdin closed and its exit code recorded before any pipe.--ranreconciles 66 accounted, 65 run, 1 NOT MEASURED:pnpm check:dual-build-cjs-loads— exit 3, PREREQUISITE NOT MET: the scan needsdist/of packages outside this diff's closure. NOT MEASURED; CI runs it.All other 65 exit 0,
check:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:doc-authoringandcheck:adr-0087-registrationamong them.node scripts/check-plugin-teardown-shape.mjs --self-testfirst exited 3 (its pinned positive control was not in the shallow clone); after fetching621a4876, exit 0, 48 cases.pnpm lint— exit 0. No export was renamed or removed, socheck-platform-checklist.mjsis not owed.Generated by Claude Code